為了對 Flutter 專案有個大略的認識,下面我們將快速實作一個簡單的應用。
$ flutter create my_app
# 或者使用更多參數:
$ flutter create --project_name my_app --org com.example --template app --empty --android-language java --ioslanguage objc my_app
實務上,雖然我們後續可以修改 iOS 的 Bundle Id 和 Android 的 Application ID,但使用 --org
參數可以避免後續修改的麻煩。其他參數僅供參考,讓我們概略了解 Flutter 命令的全面,例如可以指定平台使用的程式語言。
預設情況下,Flutter 會提供一個簡單的計數器範例。這個專案是可以直接運行的。
我們可以直接使用指令:
# 檢查裝置或模擬器是否正確
$ flutter devices
$ flutter emulators
$ cd my_app
$ flutter run
又或者使用編輯器。
使用編輯器開啟我們的專案,並切換到程式的進入點 lib/main.dart
,然後可以啟動偵錯。
Bonus: 若您使用官方推薦的 Visual Studio Code ,可以試試 Cursor 編輯器,其基於 VS Code 提供強大的 LLM 功能非常值得一試。
Flutter 支援狀態的熱替換,這個功能讓正在運行的應用程式可以更新狀態而不用整個重起或重新編譯。執行 flutter run
預設會啟動偵錯模式。偵錯模式以性能為代價提供一些協助開發的功能例如熱替換等等。效能不佳是可預期的,一旦你需要分析效能或者釋出正式版,可以換成使用效能分析(profile)或正式版(release)模式。
目前 Flutter 網頁應用程式支援 Hot Restart 而非 Hot Reload。
一般來說,從支援的編輯器或者指令介面執行應用程式便可支援 Hot Reload,無論是實體裝置或模擬器都沒問題,唯一條件是必須在偵錯模式下執行。當我們編輯程式碼並儲存時,Hot Reload 會將程式碼注入運行的 Dart VM,然後 Flutter 會自動更新介面。大部分的程式都可以進行 Hot Reload,只有少部分的情況須使用 Hot Restart。
Hot Reload 會保留狀態,不會重新執行 main()
或 initState()
。但 Hot Restart 會重啟 Flutter 應用程式。另外,某些特殊情況可能會造成 Hot Reload 失效,例如:
CupertinoTabView
的 builder
進行更改若您使用 Visual Studio Code 進行程式編輯的話,建議安裝 Flutter extension for VS Code。
類似於 JavaScript 專案中的 package.json
,Flutter 專案的相依性設定檔為 pubspec.yaml
。我們的範例需要安裝下面兩個套件,你可以選擇編輯 pubspec.yaml
然後 pub get
或者指令直接安裝:
# 完整安裝
$ flutter pub get
# 安裝套件
$ flutter pub add english_words
$ flutter pub add provider
注意:如果你使用 VS Code 並安裝了 Flutter 擴充套件的話,編輯 pubspec.yaml
會自動執行 flutter pub get
安裝套件。這些開發工具整合的相當完整,提供了良好的 DX 體驗。
pubspec.yaml
檔案設定了應用程式的基本資訊,例如當前版本,相依套件等等。
analysis_options.yaml
則是用來設定 Flutter 的程式碼該遵守的規範。隨著對 Flutter 和 Dart 的熟悉應該要設定的比較嚴格。
lib/main.dart
# 安裝套件
$ flutter pub add english_words
$ flutter pub add provider
這裡我們直接提供完整程式碼,讓您可以快速概覽。(建議您可以嘗試直接使用 macOS 或 Windows 模擬器體驗一下自適應設計)
import 'package:english_words/english_words.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider(
create: (context) => MyAppState(),
child: MaterialApp(
title: 'Namer App',
theme: ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepOrange),
),
home: MyHomePage(),
),
);
}
}
class MyAppState extends ChangeNotifier {
var current = WordPair.random();
}
class MyHomePage extends StatelessWidget {
@override
Widget build(BuildContext context) {
var appState = context.watch<MyAppState>();
return Scaffold(
body: Column(
children: [
Text('單字:'),
Text(appState.current.asLowerCase),
ElevatedButton(
onPressed: () {
print('按鈕被點擊');
},
child: Text('下一個'),
),
],
),
);
}
}
現在,在編輯器中,你可能會看到一些波浪符號的警告。別擔心,這是因為預設的 Lint 規則比較嚴格。我們可以先關閉這些規範。隨著對 Dart 深入理解,我們應該逐漸遵循這些規範提升我們程式碼的品質。
linter:
rules:
avoid_print: false
prefer_const_constructors_in_immutables: false
prefer_const_constructors: false
prefer_const_literals_to_create_immutables: false
prefer_final_fields: false
unnecessary_breaks: true
use_key_in_widget_constructors: false
首先在 lib/main.dart
void main() {
runApp(MyApp());
}
main
作為程式的進入點,告訴 Flutter 執行定義在 MyApp
的應用程式。大致上,整個風格類似於使用物件導向的宣告式方式來設計組織我們的介面。
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider(
create: (context) => MyAppState(),
child: MaterialApp(
title: 'Namer App',
theme: ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepOrange),
),
home: MyHomePage()
)
);
}
}
MyApp
繼承了 StatelessWidget
類別。Widget
就是 Flutter 應用程式的基本元素。如你所見,即便是應用程式本身也是一個組件Widget
。
MyApp
代表整個應用程式,它建立了一個應用程式層級的狀態,設定了標題,樣式主題,並指定了 home
這個首頁組件。這個組件就是整個應用程式的起點。
class MyAppState extends ChangeNotifier {
var current = WordPair.random();
}
接著,MyAppState
類別定義了應用層級的狀態。因為這是第一個 Flutter 範例,我們儘可能保持簡單。當然還有其他強大的方式可以管理 Flutter 的狀態,但 ChangeNotifier
是相對容易理解的方式。
MyAppState
定義了所需要的資料也就是狀態。這個範例只包含一個變數 - 隨機的單字。MyAppState
這個類別繼承了 ChangeNotifier
意思是一旦資料改變,它可以通知其他人資料發生變更。舉例來說,如果目前 current
改變了,應用程式中一些有訂閱的組件就會收到通知。ChangeNotifierProvider
(如在 MyApp
的程式碼中所示)來提供這個狀態,這樣應用程式中的任何組件都可以輕易獲取和使用這個狀態。非常類似於 React 的 Context Provider。class MyHomePage extends StatelessWidget {
@override
Widget build(BuildContext context) { // ← 1
var appState = context.watch<MyAppState>(); // ← 2
return Scaffold( // ← 3
body: Column( // ← 4
children: [
Text('單字:'), // ← 5
Text(appState.current.asLowerCase), // ← 6
ElevatedButton(
onPressed: () {
print('按鈕被點擊');
},
child: Text('下一個'),
),
], // ← 7
),
);
}
}
最後針對 MyHomePage
進行說明:
build()
方法,每當組件發生變化時,這個方法就會被自動執行,確保組件更新。(類似於 React Class 元件的 render
方法)。MyHomePage
使用 context.watch
訂閱了狀態。build
方法必須要回傳一個 Widget 或者 Widget 樹狀結構 。在這個範例最上層的 Widget 是 Scaffold。
雖然目前的教學,我們不會直接控制 Scaffold
,但它是一個非常實用的 Widget,並且在絕大多數實務 Flutter 應用程式中都能見到。Column
也是基本的 Widget。它可以接收 children
並將它們由上而下垂直呈現,後續我們將學習如何調整佈局。Text
很直觀的就是顯示文字Text
使用了 appState
,並存取該類別的成員也就是 current
屬性,這個屬性是 WordPair
物件實例 ,此外,WordPair
還提供了一些有用的存取子(getter)如 asPascalCase
或 asSnakeCase
。這個範例使用了 asLowerCase
。,
。這個結尾逗號其實不是必須的,例如 Column
的唯一參數 children
是不需要使用的,然而,使用結尾逗號後續增加元素或參數比較方便,也提示 Dart 的自動格式化工具在那裡換行。class MyAppState extends ChangeNotifier {
var current = WordPair.random();
// 加入方法
void getNext() {
current = WordPair.random();
notifyListeners();
}
}
新加入的 getNext()
方法使用 WordPair
重新為 current
賦值,然後執行 notifyListeners()
這是 ChangeNotifier
的一個方法,確保有 watch
訂閱 MyAppState
的地方會被通知。
Tips:這些狀態管理相關的方法來自 provider 套件。
接下來,我們需要在按鈕點擊的時候執行 getNext
:
ElevatedButton(
onPressed: () {
appState.getNext();
},
child: Text('下一個'),
),
目前我們的狀態值使用 Text(appState.current.asLowerCase)
呈現。為了調整佈局,我們可以將這一行程式碼抽成獨立的 Widget。獨立 Widget 和邏輯對於處理複雜 UI 是非常重要的方式。
Flutter 提供了重構的輔助功能來擷取獨立 Widget,但在你使用之前,請確保該行程式碼單純只存取它所需的資訊。目前這行程式讀取了整個 appState
但實際上它只需要知道目前 WordPair 的資料是什麼就好了。為此我們改寫 MyHomePage
:
若您曾經有開發 React 或 React Native 的經驗,大概可以理解我們儘可能的要避免因為不相關的狀態造成重新渲染的議題。
class MyHomePage extends StatelessWidget {
@override
Widget build(BuildContext context) {
var appState = context.watch<MyAppState>();
var pair = appState.current; // <- 這裡
return Scaffold(
body: Column(
children: [
Text('單字:'),
Text(pair.asLowerCase), // <- 這裡
ElevatedButton(
onPressed: () {
appState.getNext();
},
child: Text('下一個'),
),
],
),
);
}
}
現在 Text
不再參考整個 appState
了。接著,使用推薦的編輯器,請點擊右鍵「重構」選擇 Extract Widget (或者 macOS Cmd + . )然後輸入 BigCard
會自動幫我們建立一個新的類別 BigCard
觀看官方的教學影片學習更多好用的技巧。
class BigCard extends StatelessWidget {
const BigCard({
super.key,
required this.pair,
});
final WordPair pair;
@override
Widget build(BuildContext context) {
return Text(pair.asLowerCase);
}
}
然後在 BigCard
類別的 build()
方法,如之前的操作執行重構 Text
但這次我們不選 Extract the Widget 而是選擇 Wrap with Padding 這個操作會建立一個上層 Widget Padding
包住 Text
。接著調整預設值 8.0 為 20。
在 Flutter 中,如果可以組合就先使用組合而不是繼承。這裡我們使用單獨的 Padding
Widget 而不是 Text
的 padding
屬性來實現效果。這樣做可以讓 Widget 專注於單一職責,開發者也可以更自由地組合介面因應需求的變更。
下一步將滑鼠移至 Padding
Widget 一樣重構選擇 Wrap with widget... 輸入 Card
然後 Enter。
class BigCard extends StatelessWidget {
const BigCard({
super.key,
required this.pair,
});
final WordPair pair;
@override
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(20.0),
child: Text(pair.asLowerCase),
),
);
}
}
接著,為了更凸顯卡片,我們為它設定豐富的顏色。為了保持色調的一致性我們使用 Theme
class BigCard extends StatelessWidget {
const BigCard({
super.key,
required this.pair,
});
final WordPair pair;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context); // <- 這裡
return Card(
color: theme.colorScheme.primary, // <- 這裡
child: Padding(
padding: const EdgeInsets.all(20.0),
child: Text(pair.asLowerCase),
),
);
}
}
MyApp
設定的 ThemeData
資料。可以利用 Theme.of(context)
取得Card
的顏色,使用相同的 primary
顏色Flutter 的 Colors
類別可以方便的存取調色盤的顏色,如果要自己設定可以使用 Color.fromRGBO(0, 255, 0, 1.0)
,如果要使用 Hex 可以用 Color(0xFF00FF00)
。
注意到顏色變化的平滑過渡效果。這是隱式動畫(Implicit animation)的結果。許多 Flutter Widget 都支援在值之間平滑地進行插值(Interpolate),讓 UI 不只是在狀態之間直接切換。ElevatedButton
也支援這個效果。
現在讓調整文字樣式
// ...
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
// ↓ 加入此行
final style = theme.textTheme.displayMedium!.copyWith(
color: theme.colorScheme.onPrimary,
);
return Card(
color: theme.colorScheme.primary,
child: Padding(
padding: const EdgeInsets.all(20),
// ↓ 加入此行
child: Text(pair.asLowerCase, style: style),
),
);
}
// ...
theme.textTheme
可以存取字體樣式主題。這個類別包含成員如 bodyMedium
標準字體中等大小,caption
圖片的說明,headlineLarge
標題大型字體等等。displayMedium
屬性是用來顯示文字的大型樣式。display 表示排版,例如 displayMedium
的文件說明為 display 樣式是專為簡短、重要文字使用。displayMedium
屬性理論上可能為 null
。因為 Dart 程式語言屬於一種 Null-safe 的語言,所以它不允許你呼叫一個物件可能為 null 的屬性。在這個情況下我們使用 !
運算子 (Bang Operator)跟 Dart 保證我們知道自己在幹嘛。因為 displayMedium
在這個例子中原則上不會為 null。copyWith()
會回傳該樣式設定的副本,這個例子我們只調整字體顏色。onPrimary
屬性定義了符合 app 主色調的顏色。
- Control + Shift + R 、 Cmd + . = 重構
- Cmd + Shift + Space = 查看屬性列表
Flutter 預設支援無障礙。例如 Flutter 應用程式介面上的文字和互動元素都支援 Android 的 TalkBack 和 iOS 的VoiceOver。
不過,有時候還是需要做一些額外的設定,在這個例子輔助閱讀器可能無法唸出產生的名字。雖然人類可以辨識 cheaphead 是兩個單字組成,但輔助閱讀器不知道如何區分。
一個簡單的解法就是將 pair.asLowerCase
變成 "${pair.first} ${pair.second}"
。使用分開的兩個字取代組合好的字確保輔助閱讀器可以識別。
不過,我們可能希望保持 pair.asLowerCase
的呈現方式。這是可以使用 Text
的 semanticsLabel
語意標籤屬性可以協助輔助閱讀器理解。
return Card(
color: theme.colorScheme.primary,
elevation: 10.0,
child: Padding(
padding: const EdgeInsets.all(20.0),
child: Text(
pair.asLowerCase,
style: style,
semanticsLabel: "${pair.first} ${pair.second}", // <- 這裡
),
),
);
到此,我們的單字卡片本身的視覺效果已經不錯了,但還需要調整排版使其置中。
首先,BigCard
是 Column
的一部分, Column
會由上而下排列子元素,並且預設向上對齊。到 MyHomePage
的 build()
加入
return Scaffold(
body: Column(
mainAxisAlignment: MainAxisAlignment.center, // ← 加入
children: [
Text('單字:'),
BigCard(pair: pair),
ElevatedButton(
onPressed: () {
appState.getNext();
},
child: Text('下一個'),
),
],
),
);
class MyAppState extends ChangeNotifier {
void current = WordPair.random();
void getNext() {
current = WordPair.random();
notifyListeners();
}
var favorites = <WordPair>[];
void toggleFavorites() {
if (favorites.contains(current)) {
favorites.remove(current);
} else {
favorites.add(current);
}
notifyListeners();
}
}
在 MyHomePage
使用 ElevatedButton.icon()
建構子來建立按鈕。
class MyHomePage extends StatelessWidget {
@override
Widget build(BuildContext context) {
var appState = context.watch<MyAppState>();
var pair = appState.current;
IconData icon;
if (appState.favorites.contains(pair)) {
icon = Icons.favorite;
} else {
icon = Icons.favorite_border;
}
return Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
BigCard(pair: pair),
SizedBox(height: 10),
Row(
mainAxisSize: MainAxisSize.min,
children: [
ElevatedButton.icon(
onPressed: () {
appState.toggleFavorite();
},
icon: Icon(icon),
label: Text('讚'),
),
SizedBox(width: 10),
ElevatedButton(
onPressed: () {
appState.getNext();
},
child: Text('下一個')),
],
),
],
),
),
);
}
}
使用下面程式碼取代 MyHomePage
class MyHomePage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
body: Row(
children: [
SafeArea(
child: NavigationRail(
extended: false,
destinations: [
NavigationRailDestination(
icon: Icon(Icons.home),
label: Text('首頁'),
),
NavigationRailDestination(
icon: Icon(Icons.favorite),
label: Text('我的最愛'),
),
],
selectedIndex: 0,
onDestinationSelected: (value) {
print('selected: $value');
},
),
),
Expanded(
child: Container(
color: Theme.of(context).colorScheme.primaryContainer,
child: GeneratorPage(),
))
],
),
);
}
}
class GeneratorPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
var appState = context.watch<MyAppState>();
var pair = appState.current;
IconData icon;
if (appState.favorites.contains(pair)) {
icon = Icons.favorite;
} else {
icon = Icons.favorite_border;
}
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
BigCard(pair: pair),
SizedBox(height: 10),
Row(
mainAxisSize: MainAxisSize.min,
children: [
ElevatedButton.icon(
onPressed: () {
appState.toggleFavorite();
},
icon: Icon(icon),
label: Text('讚'),
),
SizedBox(width: 10),
ElevatedButton.icon(
onPressed: () {
appState.getNext();
},
icon: Icon(Icons.refresh),
label: Text('下一個'),
),
],
),
],
),
);
}
}
MyHomePage
的內容被提取成 GeneratorPage
。原本 Widget 中只有 Scaffold
沒有被提取。MyHomePage
包含 Row
和兩個子 Widget 分別是 SafeArea
和 Expand
SafeArea
確保它的子元素不會被硬體設計或 Status Bar 擋住,在此應用程式中,包住 NavigationRail
,以防止導覽按鈕被移動狀態列遮擋。NavigationRail
的 extended: false
為 true
。就會展開邊寬顯示標籤文字。後續教學我們會學習如何根據視窗大小自動縮放NavigationRail
有兩個 Destination 就是 Home
和 Favorites
,各自包含對應的 Icon,同時也設定了 selectedIndex
,0 表示第一個 Destination ,1 就是第二個。NavigationRail
也設定了 onDestinationSelected
當使用者選擇 Destination 時的行為。目前單純只有 print()
MyHomePage
> Scaffold
> Row
中的第二個 Widget 就是 Expand
,Expand
在 Row
和 Column
裡面非常實用 - 它們讓你能夠設計出某些子元件只佔用它們所需的空間(在這個例子中是 SafeArea),而其他小部件則應盡可能佔用剩餘的空間(在這個例子中是 Expanded)。(有點類似 CSS flex:1 的概念)。到目前為止,MyAppState
包含了全部需要的狀態。這也是為什麼其他 Widget 使用無狀態的原因。它們沒有包含任何需要變更的狀態。這些狀態不能改變自己,它們必須通過 MyAppState
後續我們需要改變這個狀況。因為我們需要紀錄 selectedIndex
同時也需要在 onDestinationSelected
執行的時候使用它的 value
。
當然我們可以將 selectedIndex
也放在 MyAppState
,但可以見得 app 的狀態會急速膨脹。
一些只和該 Widget 相關的狀態應該就自己包起來就好。
選取 MyHomePage
> Cmd + . 執行 Convert to StatefulWidget 。現在會產生 2 個類別,原本的類別繼承 StatefulWidget
和 _MyHomePageState
。這個類別繼承了 State
,因此可以管理自己的狀態。也請注意,舊的無狀態組件 StatelessWidget
的 build
方法已經移至_MyHomePageState
。它被完整地移過來 — build
方法內的內容沒有改變。
class MyHomePage extends StatefulWidget {
@override
State<MyHomePage> createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
var selectedIndex = 0;
@override
Widget build(BuildContext context) {
Widget page;
switch (selectedIndex) {
case 0:
page = GeneratorPage();
case 1:
page = Placeholder();
default:
throw UnimplementedError('no widget for $selectedIndex');
}
return Scaffold(
body: Row(
children: [
SafeArea(
child: NavigationRail(
extended: false,
destinations: [
NavigationRailDestination(
icon: Icon(Icons.home),
label: Text('Home'),
),
NavigationRailDestination(
icon: Icon(Icons.favorite),
label: Text('Favorites'),
),
],
selectedIndex: selectedIndex,
onDestinationSelected: (value) {
setState(() {
selectedIndex = value;
});
},
),
),
Expanded(
child: Container(
color: Theme.of(context).colorScheme.primaryContainer,
child: page,
))
],
),
);
}
}
Flutter 支援一些 Widget 協助開發者實現自適應的效果。例如 Wrap
跟 Row
和 Column
類似,但是一旦空間不足會自動換行。FittedBox
可以根據設定自動調整子元素。預設 NavigationRail
即便在空間足夠的情況下並不會自動顯示標籤文字。
假設我們希望在 MyHomePage
寬 600px 以上的時候顯示文字。在這個情況下可以使用 LayoutBuilder
_MyHomePageState
的 builder
將滑鼠移至 Scaffold
Builder
修改為 LayoutBuilder
(context)
改成 (context, constraints)
LayoutBuilder
的 builder
函式會在每次 constraints
發生變更的時候執行。
每當用戶調整視窗大小,旋轉行動裝置,Widget 尺寸發生變更的時候, constraints
就會取得最新的資料
class _MyHomePageState extends State<MyHomePage> {
var selectedIndex = 0;
@override
Widget build(BuildContext context) {
Widget page;
switch (selectedIndex) {
case 0:
page = GeneratorPage();
break;
case 1:
page = Placeholder();
break;
default:
throw UnimplementedError('no widget for $selectedIndex');
}
return LayoutBuilder(builder: (context, constraints) {
return Scaffold(
body: Row(
children: [
SafeArea(
child: NavigationRail(
extended: constraints.maxWidth >= 600, // ← Here.
destinations: [
NavigationRailDestination(
icon: Icon(Icons.home),
label: Text('首頁'),
),
NavigationRailDestination(
icon: Icon(Icons.favorite),
label: Text('我的最愛'),
),
],
selectedIndex: selectedIndex,
onDestinationSelected: (value) {
setState(() {
selectedIndex = value;
});
},
),
),
Expanded(
child: Container(
color: Theme.of(context).colorScheme.primaryContainer,
child: page,
),
),
],
),
);
});
}
}
Placeholder
這個組件可以協助我們先暫時取代一下還未開發的組件。接著我們要實作 FavoritesPage
來顯示我的最愛清單。
在組織清單的 UI 時可以使用 for
var messages = ['哈囉', 'Hello', '안녕하세요'];
return Column(
children: [
for (var msg in messages)
Text(msg),
]
);
最後補上完整程式碼:
import 'package:english_words/english_words.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider(
create: (context) => MyAppState(),
child: MaterialApp(
title: 'Namer App',
theme: ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
),
home: MyHomePage(),
),
);
}
}
class MyAppState extends ChangeNotifier {
var current = WordPair.random();
// 加入方法
void getNext() {
current = WordPair.random();
notifyListeners();
}
var favorites = <WordPair>[];
void toggleFavorite() {
if (favorites.contains(current)) {
favorites.remove(current);
} else {
favorites.add(current);
}
notifyListeners();
}
}
class MyHomePage extends StatefulWidget {
@override
State<MyHomePage> createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
var selectedIndex = 0;
@override
Widget build(BuildContext context) {
Widget page;
switch (selectedIndex) {
case 0:
page = GeneratorPage();
case 1:
page = FavoritesPage();
default:
throw UnimplementedError("該 Index 沒有組件");
}
return LayoutBuilder(builder: (context, constraints) {
return Scaffold(
body: Row(
children: [
SafeArea(
child: NavigationRail(
extended: constraints.maxWidth >= 600,
destinations: [
NavigationRailDestination(
icon: Icon(Icons.home),
label: Text('首頁'),
),
NavigationRailDestination(
icon: Icon(Icons.favorite),
label: Text('我的最愛'),
),
],
selectedIndex: selectedIndex, // <- 使用變數
onDestinationSelected: (value) {
setState(() {
selectedIndex = value;
});
})),
Expanded(
child: Container(
color: Theme.of(context).colorScheme.primaryContainer,
child: page,
))
],
),
);
});
}
}
class GeneratorPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
var appState = context.watch<MyAppState>();
var pair = appState.current;
IconData icon;
if (appState.favorites.contains(pair)) {
icon = Icons.favorite;
} else {
icon = Icons.favorite_border;
}
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
BigCard(pair: pair),
SizedBox(height: 10),
Row(
mainAxisSize: MainAxisSize.min,
children: [
ElevatedButton.icon(
onPressed: () {
appState.toggleFavorite();
},
icon: Icon(icon),
label: Text('讚'),
),
SizedBox(width: 10),
ElevatedButton.icon(
onPressed: () {
appState.getNext();
},
icon: Icon(Icons.refresh),
label: Text('下一個'),
)
],
),
],
),
);
}
}
class FavoritesPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
var appState = context.watch<MyAppState>();
if (appState.favorites.isEmpty) {
return Center(
child: Text('尚無我的最愛'),
);
}
return ListView(
children: [
Padding(
padding: const EdgeInsets.all(20),
child: Text('共 '
'${appState.favorites.length} 我的最愛:'),
),
for (var pair in appState.favorites)
ListTile(
leading: Icon(Icons.favorite),
title: Text(pair.asLowerCase),
),
],
);
}
}
class BigCard extends StatelessWidget {
const BigCard({
super.key,
required this.pair,
});
final WordPair pair;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final style = theme.textTheme.displayMedium!.copyWith(
color: theme.colorScheme.onPrimary,
);
return Card(
color: theme.colorScheme.primary,
child: Padding(
padding: const EdgeInsets.all(20.0),
child: Text(
pair.asLowerCase,
style: style,
semanticsLabel: "${pair.first} ${pair.second}",
),
),
);
}
}
通過這個範例我們快速的概覽了如何開發一個 Flutter 應用程式。認識了基本的如何建構介面,提供操作和狀態。
當然,雖然此時我們一定是充滿各種問題,到處是一些不是那麼直覺的參數或設定方式,而這些問題的源頭來自於我們不了解 Dart,畢竟和 HTML、JavaScript 比起來,個人是沒有任何關於 Dart 的知識,而 JSX 類似於 HTML 的組織風格和這種 OOP 風格可以說是相去甚遠。總體來說我們會需要花費大量時間熟悉語法和組件 Widget,這個過程類似於我們熟悉 HTML 標籤一樣,其實並沒有那麼可怕。後續,我們將繼續熟悉一些基本和 Flutter 會使用到的 Dart 語法,進而協助我們在範例和官方 API 文件可以讀懂它們。
flutter create
建立專案常用參數
-s
參數可以從官方文件中直接建立範例專案--org
即反過來的 domain,為 iOS 的 bundle identifier,Android 的 package name-i
-a
分別指定 iOS 或 Android 的原生語言--project-name
變更專案名稱,必須為合法的 Dart package 名稱(全小寫 + 底線)$ flutter create -n my_app -t app --org com.example --android-language kotlin --ios-language swift
基本的專案結構介紹
main.dart
組件就是 Flutter 的核心,Flutter 使用組件來渲染使用介面
組件使用 class 定義描述,在 build
方法中回傳 Widget
,最終會得到一個組件樹狀結構 Widget Tree 。一旦介面有任何變動,build
方法會再被呼叫重新渲染
組件樹狀結構邏輯上表示整個 UI ,會被用來渲染和互動操作。渲染之後會有對應的 Element 樹狀結構,就跟 React 元件和 Element 關係類似。
Flutter 支援 debug, release, profile 模式
渲染引擎為 Skia 和 Impller,前者因為卡頓的問題正汰換為 Impller
pubspec.yaml 在 Flutter 中是用來定義 Dart 套件和一些專案設定的。如果你編輯這個檔案,IDE 如 VS Code 會自動執行 flutter pub get
安裝套件,這是 Flutter 安裝套件的指令,除了 IDE 自動執行,也可以手動執行
# 完整安裝
$ flutter pub get
# 安裝套件
$ flutter pub add english_words
Emulator 和 Simulator 差異:Emulator 模擬 Android 裝置的軟硬體,而 Simulator 只模擬軟體然後使用執行機器的硬體,建議使用 Simulator 測試。
$ flutter run
# 開啟模擬器
$ flutter devices
$ flutter emulators
$ flutter run -d [DEVICE_ID]